// File: page.tsx // Path: apps/web/app/occupations/[code]/page.tsx // Project: AI Risk Index — airiskindex.io // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Copyright © 2026 Simon-Pierre Boucher. All rights reserved. // // Description: Occupation detail: sub-score tiles, dimension breakdown, per-task rater audit trail. import Link from "next/link"; import { notFound } from "next/navigation"; import { prisma } from "@airiskindex/db"; import { DIMENSIONS, HIGH_EXPOSURE_THRESHOLD, INVERTED_DIMENSIONS, WEIGHTS, pressure, type DimensionKey, } from "@airiskindex/scoring"; import { DistributionChart, ScoreBar, ShareMeter, SubScoreDotPlot, } from "@/components/score-marks"; import { formatWage, socGroupName } from "@/lib/soc-groups"; export const dynamic = "force-dynamic"; const DIMENSION_LABELS: Record = { automatability: "Task automatability", feasibility: "Technical feasibility today", cost_ratio: "Cost vs. human wage", barriers: "Adoption barriers", adoption_velocity: "Sector adoption velocity", augmentation: "Augmentation potential", }; interface DimensionAggregate { dimension: DimensionKey; weight: number; inverted: boolean; pressure: number; // 0–100, orientation applied meanRating: number; // raw 1–5 panel mean } async function loadOccupation(code: string) { const occupation = await prisma.occupation.findUnique({ where: { code }, include: { tasks: { orderBy: { importance: "desc" } } }, }); if (!occupation) return null; const score = await prisma.occupationScore.findFirst({ where: { occupationCode: code }, orderBy: { run: { createdAt: "desc" } }, include: { run: true }, }); const taskIds = occupation.tasks.map((task) => task.id); const [taskScores, ratings] = await Promise.all([ score ? prisma.taskScore.findMany({ where: { runId: score.runId, taskId: { in: taskIds } } }) : Promise.resolve([]), prisma.taskRating.findMany({ where: { taskId: { in: taskIds } }, select: { taskId: true, dimension: true, model: true, rating: true, rationale: true }, orderBy: [{ dimension: "asc" }, { model: "asc" }], }), ]); let rank: number | null = null; let scoredTotal: number | null = null; if (score) { [rank, scoredTotal] = await Promise.all([ prisma.occupationScore.count({ where: { runId: score.runId, substitution: { gt: score.substitution } }, }), prisma.occupationScore.count({ where: { runId: score.runId } }), ]); rank += 1; } const [related, indexScores] = await Promise.all([ score ? prisma.occupationScore.findMany({ where: { runId: score.runId, occupationCode: { startsWith: code.slice(0, 2), not: code }, }, orderBy: { substitution: "desc" }, take: 5, include: { occupation: { select: { code: true, title: true } } }, }) : Promise.resolve([]), score ? prisma.occupationScore.findMany({ where: { runId: score.runId }, select: { substitution: true }, }) : Promise.resolve([]), ]); return { occupation, score, taskScores, ratings, rank, scoredTotal, related, indexScores }; } function toBins(values: number[], binCount = 20): number[] { const bins = Array.from({ length: binCount }, () => 0); for (const value of values) { bins[Math.min(binCount - 1, Math.floor((value / 100) * binCount))] += 1; } return bins; } function aggregateDimensions( tasks: Array<{ id: string; importance: number | null }>, ratings: Array<{ taskId: string; dimension: string; rating: number }>, ): DimensionAggregate[] { const out: DimensionAggregate[] = []; for (const dimension of DIMENSIONS) { let weightSum = 0; let ratingSum = 0; for (const task of tasks) { const values = ratings .filter((row) => row.taskId === task.id && row.dimension === dimension) .map((row) => row.rating); if (values.length === 0) continue; const mean = values.reduce((a, b) => a + b, 0) / values.length; const weight = task.importance ?? 3; ratingSum += mean * weight; weightSum += weight; } if (weightSum === 0) continue; const meanRating = ratingSum / weightSum; out.push({ dimension, weight: WEIGHTS[dimension], inverted: INVERTED_DIMENSIONS.has(dimension), pressure: 100 * pressure(dimension, meanRating), meanRating, }); } return out; } export default async function OccupationPage({ params, }: { params: { code: string }; }): Promise { const data = await loadOccupation(params.code); if (!data) notFound(); const { occupation, score, taskScores, ratings, rank, scoredTotal, related, indexScores } = data; const byTask = new Map(taskScores.map((entry) => [entry.taskId, entry])); const ratingsByTask = new Map(); for (const row of ratings) { const list = ratingsByTask.get(row.taskId) ?? []; list.push(row); ratingsByTask.set(row.taskId, list); } const rankedTasks = [...occupation.tasks].sort( (a, b) => (byTask.get(b.id)?.substitution ?? -1) - (byTask.get(a.id)?.substitution ?? -1), ); const dimensions = aggregateDimensions(occupation.tasks, ratings); const wage = formatWage(occupation.medianWageCents); return (

{occupation.title}

{occupation.code}
{wage && Median wage {wage}} {occupation.employment != null && ( {occupation.employment.toLocaleString("en-US")} employed (US) )} {rank != null && scoredTotal != null && ( Rank #{rank} of {scoredTotal} scored · top {Math.max(1, Math.round((rank / scoredTotal) * 100))}% by substitution )}
{occupation.description && (

{occupation.description}

)} {!score ? (
This occupation has not been scored yet — its {occupation.tasks.length} tasks are queued for the multi-model rater panel. Task statements are listed below.
    {occupation.tasks.slice(0, 20).map((task) => (
  • {task.statement}
  • ))}
) : ( <>

Sub-scores

0–100 · band = confidence interval from rater disagreement

Substitution — the headline: capability discounted by cost, barriers and adoption.

Exposure — technical capability alone, regardless of whether anyone deploys it.

Augmentation — how much AI assists without replacing. High here + moderate substitution = a changing job, not a disappearing one.

Tasks on the substitution scale

{taskScores.length} rated tasks, binned by substitution score.

t.substitution))} />

Position among all scored occupations

Distribution of {indexScores.length.toLocaleString("en-US")} occupation scores; the marker is this occupation.

s.substitution))} marker={score.substitution} markerLabel={rank != null ? `#${rank} · ${score.substitution.toFixed(0)}` : score.substitution.toFixed(0)} />

Run {score.run.indexVersion} · computed {score.run.createdAt.toISOString().slice(0, 10)} · rater panel: {score.run.raterModels.join(", ")} · intervals span rater disagreement.

{dimensions.length > 0 && (

Why this score

The five weighted dimensions of the composite, averaged across this occupation's tasks (importance-weighted, panel mean). Exact weights and formulas:{" "} /api/v1/methodology .

{dimensions.map((dim) => (
{DIMENSION_LABELS[dim.dimension]} w {(dim.weight * 100).toFixed(0)}% {dim.inverted && ( inverted — strong barriers lower the score )} {dim.pressure.toFixed(0)}
))}
)}

Task breakdown{" "} ({rankedTasks.length} tasks)

Substitution pressure per task, weighted by O*NET importance in the composite. Expand a task for the full rater audit trail — every rating, every model, every rationale.

{rankedTasks.map((task) => { const ts = byTask.get(task.id); const taskRatings = ratingsByTask.get(task.id) ?? []; return (

{task.statement}

{ts ? ts.substitution.toFixed(0) : "—"}
{ts ? ( ) : (
)}
{ts && (

CI {ts.substitutionLow.toFixed(0)}–{ts.substitutionHigh.toFixed(0)} · exposure {ts.exposure.toFixed(0)} · augmentation{" "} {ts.augmentation.toFixed(0)} {task.importance != null && <> · importance {task.importance.toFixed(1)}/5} {taskRatings.length > 0 && <> · click for rater detail}

)}
{taskRatings.length > 0 && (
{taskRatings.map((row, index) => ( ))}
Panel ratings per dimension for this task
Dimension Model Rating Rationale
{DIMENSION_LABELS[row.dimension as DimensionKey] ?? row.dimension} {row.model} {row.rating}/5 {row.rationale}
)}
); })}
{related.length > 0 && (

Related occupations — {socGroupName(occupation.code)}

    {related.map((row) => (
  • {row.occupation.title} {row.substitution.toFixed(0)}
  • ))}
)}

How to read this

A high substitution score does not mean this job disappears — it means a large share of its current tasks face replacement pressure, so the mix of tasks is likely to change. High augmentation alongside substitution typically means the occupation reorganizes around the protected tasks. Wide confidence intervals mean the rater panel disagreed: treat those scores as open questions, not verdicts.

What would change this score

New model capabilities (automatability, feasibility), falling inference costs (cost ratio), regulation and licensing shifts (barriers), and measured sector adoption (velocity) all re-enter at every index release. Each release is recomputed, versioned and kept queryable — scores are claims with a date on them, not permanent labels.

)}
); }